當應用程式較小且簡單時,signal就足以建構 state management 解決方案。當應用程式擴充時,我們應該考慮遷移到開源庫,例如 NGRX
、NGRX Signal Store
和 TanStack Store
。
由於不同的 method signatures,從一個庫遷移到另一個庫可能會導致組件發生重大變更。一種解決方案是將 facade pattern
應用於服務 (service)。然後,組件透過 interface 進行通信,而不是直接與 store 互動。
import { InputItem } from "../types/account.type";
import { AccountSummary } from "../types/store.type";
import { Signal } from '@angular/core';
export interface AccountContract {
summary: Signal<AccountSummary>;
update(data: InputItem): void;
}
import { computed, Injectable, signal, Signal } from '@angular/core';
import { AccountRecords, InputItem } from "../types/account.type";
import { AccountSummary } from '../types/store.type';
import { ItemType } from '../enums/account.enum';
import { AccountContract } from '../interfaces/account.interface';
// Angular signal implementation
@Injectable()
export class AccountFacade implements AccountContract {
update({ type, date, amount, description }: InputItem) {
const newItem = { date, amount, description };
const state = this.#state;
if (type === ItemType.INCOME) {
state.update((value) => ({
incomes: [...value.incomes, newItem],
expenses: value.expenses,
}));
} else if (type === ItemType.EXPENSE) {
state.update((value) => ({
incomes: value.incomes,
expenses: [...value.expenses, newItem],
}));
}
}
#state = signal<AccountRecords>({
incomes: [],
expenses: [],
});
#totalIncomes = computed(() => this.#state().incomes.reduce((acc, item) =>
acc + item.amount, 0));
#totalExpenses = computed(() => this.#state().expenses.reduce((acc, item) => acc + item.amount, 0));
summary = computed<AccountSummary>(() => ({
incomes: this.#state().incomes,
expenses: this.#state().expenses,
totalIncomes: this.#totalIncomes(),
totalExpenses: this.#totalExpenses(),
hasMoneyLeft: this.#totalIncomes() > this.#totalExpenses(),
surplus: this.#totalIncomes() - this.#totalExpenses()
}));
}
AccountFacade
是一項使用 signal 和 computed signal 來實現 account store 的服務 (service)。 該服務實作 (implements) 了 AccountContract
接口 (interface),因此它有一個summary
成員和一個 update
方法。
npm install @tanstack/angular-store
安裝 Angular tanstack store。
import { AccountContract } from "../interfaces/account.interface";
import { AccountRecords, InputItem } from "../types/account.type";
import { Injectable, Signal } from '@angular/core';
import { Store } from '@tanstack/store';
import { injectStore } from '@tanstack/angular-store';
import { ItemType } from "../enums/account.enum";
import { AccountSummary } from "../types/store.type";
// Tanstack Store implementation
@Injectable()
export class TanstackAccountFacade implements AccountContract {
#store = new Store<AccountRecords>({
incomes: [],
expenses: [],
});
#totalIncomes = injectStore(this.#store, (state) => state.incomes.reduce((acc, item) => acc + item.amount, 0));
#totalExpenses = injectStore(this.#store, (state) => state.expenses.reduce((acc, item) => acc + item.amount, 0));
summary: Signal<AccountSummary> = injectStore(this.#store, (state) => ({
incomes: state.incomes,
expenses: state.expenses,
totalIncomes: this.#totalIncomes(),
totalExpenses: this.#totalExpenses(),
hasMoneyLeft: this.#totalIncomes() > this.#totalExpenses(),
surplus: this.#totalIncomes() - this.#totalExpenses()
}))
update({ type, date, amount, description }: InputItem) {
const newItem = { date, amount, description };
if (type === ItemType.INCOME) {
this.#store.setState((prevState) => ({
incomes: [...prevState.incomes, newItem],
expenses: prevState.expenses,
}));
} else if (type === ItemType.EXPENSE) {
this.#store.setState((prevState) => ({
incomes: prevState.incomes,
expenses: [...prevState.expenses, newItem],
}));
}
}
}
TanstackAccountFacade
是一項使用 TanStack Store
程式庫來實現 account store 的服務 (service)。該服務 (service) 還實作 (implements) AccountContract
介面 (interface) 來履行合約。
export type StoreType = 'Signal' | 'TanStack';
export const STORE_TYPE = new InjectionToken<StoreType>('STORE_TYPE');
export const STORE_TOKEN = new InjectionToken<AccountContract>('STORE_TOKEN');
STORE_TYPE
和 STORE_TOKEN
InjectionToken 決定在 AppAccountWrapperComponent
組件中注入 AccountFacade
還是 TanStackAccountFacade
。
import { STORE_TOKEN, STORE_TYPE } from "./constants/account.constant";
import { AccountFacade } from "./stores/account.facade";
import { TanstackAccountFacade } from "./stores/tanstack-account.facade";
import { inject } from '@angular/core';
export const providers = [
{
provide: STORE_TYPE,
useValue: 'TanStack',
},
{
provide: STORE_TOKEN,
useFactory: () => {
const type = inject(STORE_TYPE);
console.log('type', type);
if (type === 'Signal') {
return new AccountFacade();
} else if (type === 'TanStack') {
return new TanstackAccountFacade();
}
throw new Error('Wrong type');
}
}
]
import { providers } from './account.provider';
@Component({
selector: 'app-account-wrapper',
standalone: true,
imports: [AppAccountFormComponent, AppAccountListComponent, AppAccountSummaryComponent],
template: `
@let summary = store.summary();
<div class="photo-output-wrapper">
<app-account-form class="form" />
<h2>Balance Sheet</h2>
<app-account-list title='Incomes' [items]="summary.incomes" />
<app-account-list title='Expenses' [items]="summary.expenses" />
<app-account-summary [summary]="summary"
/>
</div>
`,
providers,
})
export default class AppAccountWrapperComponent {}
在 providers array 中,組件提供 STORE_TYPE
的值。 STORE_TOKEN
InjectionToken 的 useFactory
函數注入 STORE_TYPE
來取得值。 當值為 Signal
時,程式碼會實例化 (instantiate) AccountFacade
。當值為 TanStack
時,程式碼實例化 (instantiate) TanstackAccountFacade
。否則,該函數會拋出錯誤訊息。
export default class AppAccountWrapperComponent {
acountForm = viewChild.required(AppAccountFormComponent);
store = inject(STORE_TOKEN);
constructor() {
effect((OnCleanUp) => {
const sub = this.acountForm().submittedValues.subscribe((data) => this.store.update(data as InputItem));
OnCleanUp(() => sub.unsubscribe());
});
}
}
AppAccountFormComponent
組件注入 STORE_TOKEN
並將 store 指派給 store
變數。該組件的作用是呼叫 store 的 update
方法以將記錄新增至 state
變數。組件的 HTML 範本不受影響。
如果我想使用 NGRX Signal Store
來實現 store,我將執行以下操作:
STORE_TYPE
中新增的類型成員,例如 ngrx-signal-store。STORE_TOKEN
的 useFactory
函數以實例化 (initialize) 新服務並傳回它。最重要的是服務不必更改,因為更改在 account.provider.ts
檔案中。
InjectionToken
和 useFactory
注入正確的服務 (service)。鐵人賽的第 28 天到此結束。